Crossy rpad

This sketch recreates the classic Crossy Road game: a red square hops between lanes of grass, traffic, and water, riding logs and dodging cars while the camera scrolls upward to follow progress. It tracks a score based on distance traveled and persists a high score across sessions using the browser's local storage.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make traffic much busier — Raising OBSTACLE_DENSITY packs more cars and logs into every lane, making the game noticeably harder.
  2. Recolor the player — fill() controls what color gets drawn next, so changing its arguments instantly changes the player's square color.
  3. Speed up the cars — Doubling CAR_SPEED_FACTOR makes every road lane's traffic race by twice as fast, raising the difficulty a lot.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch builds a full Crossy Road style game where a player square hops forward through procedurally generated lanes of grass, road, and water, dodging cars and riding logs to avoid falling in. What makes it interesting is how much it accomplishes with simple rectangles: a scrolling world illusion, endless lane generation, moving obstacles in both directions, and win/lose logic, all built from p5.js primitives like rect(), constrain(), and random(). It leans on object-oriented JavaScript (ES6 classes) to organize the Player, Obstacle, and Lane entities, and on a finite state machine (START, PLAYING, GAME_OVER) to control what's drawn each frame.

The code is organized into three classes - Player, Obstacle, and Lane - each responsible for drawing and updating itself, plus a set of p5.js lifecycle functions (setup, draw, keyPressed) that tie everything together. By studying it you'll learn how to fake camera scrolling by offsetting draw positions instead of moving the whole world, how to detect axis-aligned bounding box collisions, how to recycle and regenerate off-screen objects for infinite procedural content, and how to persist data between sessions with getItem/storeItem.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas, loads any saved high score from local storage, and calls resetGame() to build the player and the first batch of lanes, leaving the game in a 'START' state showing a title screen.
  2. Every frame, draw() clears the background and calls a different function depending on gameState - drawStartScreen(), playGame(), or drawGameOverScreen() - so only one screen is ever active at a time.
  3. During PLAYING, playGame() first computes scrollOffset from the difference between the player's fixed canvas position and their real progress in the game world, then draws every lane and obstacle shifted by that offset - this is what creates the illusion that the world scrolls down as the player moves up.
  4. Each Lane updates its own Obstacle objects (cars or logs), removing ones that scroll off-screen and spawning new ones at the leading edge so the traffic feels endless.
  5. Pressing an arrow key calls player.move(), which shifts the player's x/y and updates score; checkSafety() then tests the player's bounding box against the current lane's obstacles - colliding with a car or falling in water without a log sets gameState to GAME_OVER.
  6. On GAME_OVER, drawGameOverScreen() compares the current score to the stored high score, saves a new record if beaten, and waits for any key press to call resetGame() again and restart the loop.

🎓 Concepts You'll Learn

ES6 classes for game objectsFinite state machines (START/PLAYING/GAME_OVER)Camera scrolling via draw-position offsetAxis-aligned bounding box (AABB) collision detectionProcedural, infinite obstacle generationArray filtering to recycle off-screen objectsPersistent data with getItem/storeItem (local storage)

📝 Code Breakdown

Player constructor()

This is the class constructor that runs once when `new Player(...)` is called in resetGame(). It sets up the player's starting state before any drawing or movement happens.

constructor(x, y) {
    this.x = x;
    this.y = y; // Fixed Y position on the canvas
    this.size = PLAYER_SIZE;
    this.safe = true;
    this.isOnLog = false;
    this.currentLane = null;
  }
Line-by-line explanation (6 lines)
this.x = x;
Stores the player's horizontal canvas position.
this.y = y; // Fixed Y position on the canvas
The player's vertical position on screen never actually changes - only the world scrolls around it.
this.size = PLAYER_SIZE;
Sets the width/height of the player's square from the global constant.
this.safe = true;
Tracks whether the player is currently alive/safe; used to trigger game over.
this.isOnLog = false;
Flags whether the player is currently riding a log in a water lane.
this.currentLane = null;
Keeps a reference to the lane the player is standing in, set later by checkSafety().

Player.draw()

Every game object in this sketch has its own draw() method, keeping rendering logic bundled with the object it belongs to - a common OOP pattern in games.

draw() {
    fill(255, 0, 0); // Red player
    rectMode(CENTER);
    rect(this.x, this.y, this.size, this.size);
  }
Line-by-line explanation (3 lines)
fill(255, 0, 0); // Red player
Sets the fill color to bright red for the player square.
rectMode(CENTER);
Makes rect() draw from the shape's center instead of its top-left corner, so this.x/this.y is the middle of the square.
rect(this.x, this.y, this.size, this.size);
Draws the player as a square using its fixed canvas position and size.

Player.move()

move() is called from keyPressed() whenever an arrow key is hit. Separating 'canvas position' (this.y, fixed) from 'world position' (playerActualY, ever-changing) is the trick that makes the scrolling illusion work.

move(dx, dy) {
    // dx is step size (e.g., LANE_HEIGHT), dy is laneHeight
    this.x = constrain(this.x + dx, this.size / 2, width - this.size / 2);
    playerActualY -= dy; // Player's global Y position decreases when moving up
    score = max(score, abs(playerActualY - playerCanvasY)); // Score is distance from initial Y
  }
Line-by-line explanation (3 lines)
this.x = constrain(this.x + dx, this.size / 2, width - this.size / 2);
Moves the player horizontally by dx, but constrain() clamps the result so the player can never leave the canvas edges.
playerActualY -= dy;
Updates the player's true world position - moving up (positive dy) decreases playerActualY, which is how progress is tracked separately from the fixed on-screen y.
score = max(score, abs(playerActualY - playerCanvasY));
The score is the farthest distance ever traveled from the starting point - max() means score only ever increases, even if the player later steps backward.

Player.updateOnLog()

This method is called from inside checkSafety() every frame the player is in a water lane, simulating being physically carried by a moving log.

updateOnLog(logSpeed) {
    if (this.isOnLog) {
      this.x += logSpeed;
      this.x = constrain(this.x, this.size / 2, width - this.size / 2);
    }
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional On-Log Check if (this.isOnLog) {

Only carries the player along if they are currently standing on a log

if (this.isOnLog) {
Only applies log movement if the player is actually riding a log this frame.
this.x += logSpeed;
Drags the player sideways at the same speed as the log they're standing on.
this.x = constrain(this.x, this.size / 2, width - this.size / 2);
Keeps the player from being carried off the edge of the canvas by a fast log.

Player.checkSafety()

checkSafety() is the game's core rules engine - it runs every frame in playGame() and is what turns simple rectangle math into life-or-death gameplay decisions.

🔬 This is a classic AABB (axis-aligned bounding box) overlap test using four comparisons. What happens if you shrink the player's hitbox by replacing `this.size / 2` with `this.size / 4` in all four lines - does the game feel more forgiving?

        if (this.x - this.size / 2 < obs.x + obs.w &&
            this.x + this.size / 2 > obs.x &&
            this.y - this.size / 2 < obs.y + obs.h + scrollOffset &&
            this.y + this.size / 2 > obs.y + scrollOffset) {
          this.safe = false; // Collided with car
          break;
        }
checkSafety(currentLane) {
    this.safe = true;
    this.isOnLog = false;
    this.currentLane = currentLane;

    if (currentLane.type === 'road') {
      for (let obs of currentLane.obstacles) {
        // Check collision using player's fixed canvas Y and obstacle's scrolled Y
        if (this.x - this.size / 2 < obs.x + obs.w &&
            this.x + this.size / 2 > obs.x &&
            this.y - this.size / 2 < obs.y + obs.h + scrollOffset &&
            this.y + this.size / 2 > obs.y + scrollOffset) {
          this.safe = false; // Collided with car
          break;
        }
      }
    } else if (currentLane.type === 'water') {
      let onALog = false;
      for (let obs of currentLane.obstacles) {
        // Check if player's X range overlaps with the log's X range
        if (this.x - this.size / 2 < obs.x + obs.w &&
            this.x + this.size / 2 > obs.x) {
          onALog = true;
          this.isOnLog = true;
          this.updateOnLog(obs.speed); // Move player with log
          break;
        }
      }
      if (!onALog) {
        this.safe = false; // Drowned
      }
    }
    // Grass lanes are always safe
  }
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Car Collision Check for (let obs of currentLane.obstacles) {

Tests the player's bounding box against every car in the lane to detect a collision

for-loop Log Overlap Check for (let obs of currentLane.obstacles) {

Checks whether the player's x-range overlaps any log, meaning they are safely floating

this.safe = true;
Assumes the player is safe by default, then this function tries to prove otherwise.
if (currentLane.type === 'road') {
Road lanes use full rectangle overlap (x AND y) since cars can be anywhere vertically within the lane.
this.safe = false; // Collided with car
If any car's box overlaps the player's box, mark the player unsafe and stop checking further obstacles.
} else if (currentLane.type === 'water') {
Water lanes only check horizontal (x) overlap, because the player's vertical position on screen is fixed - what matters is whether they're standing on any log.
this.updateOnLog(obs.speed); // Move player with log
If standing on a log, immediately shifts the player sideways with it this frame.
if (!onALog) { this.safe = false; // Drowned }
If no log was found under the player in a water lane, they've fallen in and the game ends.

Obstacle constructor()

Obstacle is a small, reusable data class for anything that moves horizontally across a lane - both cars and logs use the exact same class.

constructor(x, y, w, h, speed, type) {
    this.x = x;
    this.y = y; // Y position relative to scrollOffset 0
    this.w = w;
    this.h = h;
    this.speed = speed;
    this.type = type;
  }
Line-by-line explanation (3 lines)
this.y = y; // Y position relative to scrollOffset 0
Obstacles store an unscrolled y position; the current scrollOffset is only added at draw time, not stored.
this.speed = speed;
How many pixels this obstacle moves per frame - positive moves right, negative moves left.
this.type = type;
Either 'car' or 'log', which determines its color and collision behavior.

Obstacle.draw()

Notice draw() never modifies this.y directly - it only offsets the position for rendering, keeping the obstacle's true world position untouched for collision math.

draw(scrollOffset) {
    if (this.type === 'car') {
      fill(0, 0, 200); // Blue car
      rectMode(CORNER);
      rect(this.x, this.y + scrollOffset, this.w, this.h);
    } else if (this.type === 'log') {
      fill(139, 69, 19); // Brown log
      rectMode(CORNER);
      rect(this.x, this.y + scrollOffset, this.w, this.h);
    }
  }
Line-by-line explanation (1 lines)
rect(this.x, this.y + scrollOffset, this.w, this.h);
Adds the passed-in scrollOffset to the obstacle's stored y before drawing - this is the actual 'camera scroll' effect applied at render time.

Obstacle.update()

A minimal update() method - all the interesting behavior (removal, respawning) lives in Lane.update() instead.

update() {
    this.x += this.speed;
  }
Line-by-line explanation (1 lines)
this.x += this.speed;
Moves the obstacle horizontally each frame by its speed - negative speeds move it left, positive speeds move it right.

Obstacle.isOffScreen()

This method is used by Lane.update() to filter out obstacles that have scrolled completely away, so they can be recycled into new ones.

isOffScreen() {
    if (this.speed > 0) {
      return this.x > width;
    } else {
      return this.x + this.w < 0;
    }
  }
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Direction-Aware Bounds Check if (this.speed > 0) {

Checks the right edge for rightward movers and the left edge for leftward movers

if (this.speed > 0) { return this.x > width; }
For obstacles moving right, they're off-screen once their left edge passes the right side of the canvas.
else { return this.x + this.w < 0; }
For obstacles moving left, they're off-screen once their right edge passes the left side of the canvas.

Lane constructor()

Lane is the biggest class in the sketch - it owns a strip of the game world, its background color, and every obstacle inside it.

constructor(y, type, speed, direction) {
    this.y = y; // Top Y position relative to scrollOffset 0
    this.type = type;
    this.speed = speed * direction;
    this.direction = direction;
    this.obstacles = [];
    this.generateObstacles();
  }
Line-by-line explanation (2 lines)
this.speed = speed * direction;
Combines a base speed with a direction (1 or -1) so lanes can send obstacles either left or right.
this.generateObstacles();
Immediately fills this lane with an initial set of cars/logs when it's created.

Lane.generateObstacles()

This method runs once when a Lane is first created, seeding it with a believable spread of traffic or logs before the game even starts drawing it.

🔬 OBSTACLE_DENSITY is compared against a fresh random() roll for every slot. What happens visually if you hard-code this to `if (random() < 0.9)` - do lanes become nearly solid walls of traffic?

      if (random() < OBSTACLE_DENSITY) {
generateObstacles() {
    this.obstacles = [];
    let x = random(-width, 0); // Start some obstacles off-screen to the left

    while (x < width * 2) { // Generate obstacles across two screen widths
      if (random() < OBSTACLE_DENSITY) {
        let obsWidth;
        if (this.type === 'road') {
          obsWidth = random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 3); // Car width
        } else if (this.type === 'water') {
          obsWidth = random(LANE_HEIGHT * 2, LANE_HEIGHT * 4); // Log width
        }
        let obsHeight = LANE_HEIGHT * 0.7; // Obstacle height
        this.obstacles.push(new Obstacle(x, this.y + LANE_HEIGHT * 0.15, obsWidth, obsHeight, this.speed, this.type));
        x += obsWidth + random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 4); // Gap between obstacles
      } else {
        x += LANE_HEIGHT * random(1, 3); // Gap if no obstacle
      }
    }
  }
Line-by-line explanation (4 lines)

🔧 Subcomponents:

while-loop Fill Lane With Obstacles while (x < width * 2) {

Walks a virtual cursor across two screen-widths of space, randomly placing obstacles with gaps between them

conditional Obstacle Density Roll if (random() < OBSTACLE_DENSITY) {

Randomly decides whether this slot gets an obstacle or stays empty

let x = random(-width, 0);
Starting cursor position begins somewhere off-screen to the left so obstacles don't all pop in visibly at once.
while (x < width * 2) {
Keeps placing obstacles/gaps until the cursor has walked across two full screen widths, giving a buffer of content.
if (random() < OBSTACLE_DENSITY) {
Rolls a random number 0-1 and compares it to the density constant - lower OBSTACLE_DENSITY means fewer obstacles spawn.
x += obsWidth + random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 4); // Gap between obstacles
Advances the cursor past the obstacle just placed, plus a random gap, before considering the next slot.

Lane.draw()

Lane.draw() shows the 'draw at scrolled position, keep true position unchanged' pattern used everywhere in this sketch to fake camera movement without ever actually moving the canvas.

🔬 This loop spaces road markings by LANE_HEIGHT * 1.5. What happens if you shrink that spacing to LANE_HEIGHT * 0.5 - do the roads look busier or more like a highway?

      for (let i = 0; i < width; i += LANE_HEIGHT * 1.5) {
        fill(255);
        rect(i, this.y + scrollOffset + LANE_HEIGHT / 2 - 5, LANE_HEIGHT * 0.75, 10);
      }
draw(scrollOffset) {
    noStroke();
    if (this.type === 'grass') {
      fill(0, 200, 0); // Green grass
    } else if (this.type === 'road') {
      fill(50); // Dark grey road
      // Draw road markings
      for (let i = 0; i < width; i += LANE_HEIGHT * 1.5) {
        fill(255);
        rect(i, this.y + scrollOffset + LANE_HEIGHT / 2 - 5, LANE_HEIGHT * 0.75, 10);
      }
    } else if (this.type === 'water') {
      fill(0, 0, 150); // Blue water
    }
    rectMode(CORNER);
    rect(0, this.y + scrollOffset, width, LANE_HEIGHT);

    for (let obs of this.obstacles) {
      obs.draw(scrollOffset);
    }
  }
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Road Marking Stripes for (let i = 0; i < width; i += LANE_HEIGHT * 1.5) {

Draws evenly-spaced white dash marks across a road lane to make it read as a street

for-loop Draw Each Obstacle for (let obs of this.obstacles) {

Draws every car or log currently in this lane

if (this.type === 'grass') { fill(0, 200, 0); }
Picks the lane's base background color depending on its type.
for (let i = 0; i < width; i += LANE_HEIGHT * 1.5) {
Steps across the full canvas width placing a white dash roughly every 1.5 lane-heights, mimicking road markings.
rect(0, this.y + scrollOffset, width, LANE_HEIGHT);
Draws the full-width background strip for this lane, shifted down/up by scrollOffset so it lines up with the scrolling world.
for (let obs of this.obstacles) { obs.draw(scrollOffset); }
Draws every obstacle in this lane, passing along the same scrollOffset so everything moves together.

Lane.update()

Lane.update() demonstrates the 'object pooling by recycling' pattern common in games: rather than tracking a giant infinite world, only nearby obstacles exist, and they're continuously deleted and respawned to simulate endlessness.

🔬 This single line is what makes the traffic endless. What do you think happens if you comment this line out entirely - will the obstacle array keep growing forever?

    this.obstacles = this.obstacles.filter(obs => !obs.isOffScreen());
update() {
    for (let obs of this.obstacles) {
      obs.update();
    }

    // Remove off-screen obstacles and add new ones
    this.obstacles = this.obstacles.filter(obs => !obs.isOffScreen());

    // Add new obstacles if there's space on the leading edge
    let leadingEdge = this.direction > 0 ? this.obstacles[this.obstacles.length - 1] : this.obstacles[0];
    if (!leadingEdge || (this.direction > 0 && leadingEdge.x < width) || (this.direction < 0 && leadingEdge.x + leadingEdge.w > 0)) {
      let xPos;
      if (this.direction > 0) { // Moving right, add to left
        xPos = leadingEdge ? leadingEdge.x - random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 4) - LANE_HEIGHT * 3 : -LANE_HEIGHT * 3;
      } else { // Moving left, add to right
        xPos = leadingEdge ? leadingEdge.x + leadingEdge.w + random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 4) : width;
      }

      let obsWidth;
      if (this.type === 'road') {
        obsWidth = random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 3);
      } else if (this.type === 'water') {
        obsWidth = random(LANE_HEIGHT * 2, LANE_HEIGHT * 4);
      }
      let obsHeight = LANE_HEIGHT * 0.7;
      this.obstacles.push(new Obstacle(xPos, this.y + LANE_HEIGHT * 0.15, obsWidth, obsHeight, this.speed, this.type));
    }
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Move Every Obstacle for (let obs of this.obstacles) {

Calls update() on each obstacle so it advances by its speed

calculation Recycle Off-Screen Obstacles this.obstacles = this.obstacles.filter(obs => !obs.isOffScreen());

Removes any obstacle that has fully scrolled off the canvas

conditional Leading Edge Spawn Check if (!leadingEdge || (this.direction > 0 && leadingEdge.x < width) || (this.direction < 0 && leadingEdge.x + leadingEdge.w > 0)) {

Decides whether there is room to spawn a fresh obstacle at the front of the moving line

this.obstacles = this.obstacles.filter(obs => !obs.isOffScreen());
Array.filter() builds a new array keeping only obstacles that are NOT off-screen, effectively deleting the rest.
let leadingEdge = this.direction > 0 ? this.obstacles[this.obstacles.length - 1] : this.obstacles[0];
Finds whichever obstacle is at the 'front' of the lane's direction of travel, since that's where new obstacles need to be added.
xPos = leadingEdge ? leadingEdge.x - random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 4) - LANE_HEIGHT * 3 : -LANE_HEIGHT * 3;
Places a new obstacle just behind the current leading one (with a random gap) so traffic looks continuous rather than randomly popping in.

setup()

setup() runs exactly once when the sketch starts, and is the natural place to size the canvas and initialize starting state.

function setup() {
  createCanvas(windowWidth, windowHeight);
  highestScore = getItem('crossyRoadHighestScore') || 0; // Load highest score
  resetGame();
  userStartAudio(); // Required for audio on mobile, even if not using sound yet
}
Line-by-line explanation (4 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window.
highestScore = getItem('crossyRoadHighestScore') || 0;
Reads a saved high score from the browser's local storage via p5's getItem(); if nothing was saved yet, defaults to 0.
resetGame();
Builds the player and the initial set of lanes so the game is ready to play (or show its start screen).
userStartAudio();
Unlocks audio playback on mobile browsers, which normally block sound until a user gesture - a defensive line even though this sketch doesn't currently play sounds.

draw()

draw() runs continuously at the sketch's frame rate. Here it's kept intentionally tiny, acting only as a dispatcher to the right state-specific function - a clean way to structure a multi-screen game.

function draw() {
  background(220);

  if (gameState === 'START') {
    drawStartScreen();
  } else if (gameState === 'PLAYING') {
    playGame();
  } else if (gameState === 'GAME_OVER') {
    drawGameOverScreen();
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Game State Dispatch if (gameState === 'START') {

Chooses which screen-drawing function to call based on the current gameState

background(220);
Clears the whole canvas to light grey every frame before anything else is drawn.
if (gameState === 'START') { drawStartScreen(); }
Shows the title screen while gameState is 'START'.
} else if (gameState === 'PLAYING') { playGame(); }
Runs the entire game loop - lane updates, drawing, collision checks - while actively playing.
} else if (gameState === 'GAME_OVER') { drawGameOverScreen(); }
Freezes gameplay and shows the game over overlay once the player has died.

drawStartScreen()

This function is purely presentational - it just draws UI text and reads no input itself; input handling for starting the game lives in keyPressed()/touchStarted().

function drawStartScreen() {
  fill(0);
  textAlign(CENTER, CENTER);
  textSize(48);
  text("CROSSY ROAD", width / 2, height / 2 - 50);
  textSize(24);
  text("Use Arrow Keys to Move", width / 2, height / 2 + 20);
  text("Press Any Key to Start", width / 2, height / 2 + 60);
  textSize(18);
  text("Highest Score: " + highestScore, width / 2, height / 2 + 100);
}
Line-by-line explanation (3 lines)
textAlign(CENTER, CENTER);
Centers all following text horizontally and vertically around the coordinates given to text().
text("CROSSY ROAD", width / 2, height / 2 - 50);
Draws the game's title text centered on screen, offset upward from the middle.
text("Highest Score: " + highestScore, width / 2, height / 2 + 100);
Displays the persisted best score below the instructions, using string concatenation to combine text and a number.

playGame()

playGame() is the game's central loop, run every frame while playing - it ties together scrolling, lane recycling, drawing, and collision checks in one place.

🔬 This loop deletes lanes once they scroll past the bottom of the screen. What happens if you change `> height` to `> height + 500` so lanes stick around 500px longer after leaving view?

  for (let i = lanes.length - 1; i >= 0; i--) {
    let lane = lanes[i];
    lane.update();
    lane.draw(scrollOffset);

    // Remove lanes that are off-screen at the bottom
    if (lane.y + LANE_HEIGHT + scrollOffset > height) {
      lanes.splice(i, 1);
    }
  }
function playGame() {
  // Update scrollOffset as player moves up
  scrollOffset = playerCanvasY - playerActualY;

  // Draw and update lanes
  for (let i = lanes.length - 1; i >= 0; i--) {
    let lane = lanes[i];
    lane.update();
    lane.draw(scrollOffset);

    // Remove lanes that are off-screen at the bottom
    if (lane.y + LANE_HEIGHT + scrollOffset > height) {
      lanes.splice(i, 1);
    }
  }

  // Add new lanes at the top as they are removed from bottom
  while (lanes.length < NUM_PREGEN_LANES) {
    let lastLane = lanes[lanes.length - 1];
    let newLaneY = lastLane ? lastLane.y - LANE_HEIGHT : -LANE_HEIGHT; // Position above the last lane, or at -LANE_HEIGHT if first lane
    lanes.push(generateNewLane(newLaneY));
  }

  // Draw player
  player.draw();

  // Find the lane the player is currently in (based on playerActualY)
  let playerLane = null;
  for (let lane of lanes) {
    if (playerActualY >= lane.y && playerActualY < lane.y + LANE_HEIGHT) {
      playerLane = lane;
      break;
    }
  }

  if (playerLane) {
    player.checkSafety(playerLane);
    if (!player.safe) {
      gameState = 'GAME_OVER';
    }
  } else {
    // If player somehow moved off the top of all lanes (shouldn't happen with current logic)
    gameState = 'GAME_OVER';
  }

  // Check if player moved off canvas left/right
  if (player.x < PLAYER_SIZE / 2 || player.x > width - PLAYER_SIZE / 2) {
    gameState = 'GAME_OVER';
  }
  
  // Check if player moved off the bottom of the starting area (game over)
  // This prevents moving infinitely down
  if (playerActualY > playerCanvasY + LANE_HEIGHT) {
    gameState = 'GAME_OVER';
  }

  // Display score
  fill(0);
  textAlign(LEFT, TOP);
  textSize(24);
  text("Score: " + score, 10, 10);
  text("Highest: " + highestScore, 10, 40);
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Update & Draw All Lanes for (let i = lanes.length - 1; i >= 0; i--) {

Iterates lanes backwards so splicing out off-screen lanes doesn't skip elements

while-loop Keep Lane Count Topped Up while (lanes.length < NUM_PREGEN_LANES) {

Adds fresh lanes above the highest existing lane whenever old ones get removed

for-loop Locate Player's Current Lane for (let lane of lanes) {

Searches the lanes array for the one whose y-range contains the player's current world position

scrollOffset = playerCanvasY - playerActualY;
The heart of the scrolling illusion: as playerActualY decreases (player moves up), scrollOffset grows, pushing everything drawn further down the screen relative to its stored position.
for (let i = lanes.length - 1; i >= 0; i--) {
Loops backward through the lanes array - important because lane.splice(i, 1) inside the loop removes an item, and looping backward avoids skipping the next element.
if (lane.y + LANE_HEIGHT + scrollOffset > height) { lanes.splice(i, 1); }
Once a lane has scrolled completely below the bottom of the screen, it's deleted from the array to save memory.
while (lanes.length < NUM_PREGEN_LANES) {
Ensures there are always NUM_PREGEN_LANES lanes ready above the player, generating new ones as old ones are removed.
if (playerActualY >= lane.y && playerActualY < lane.y + LANE_HEIGHT) {
Tests whether the player's world y-coordinate falls within this lane's vertical range - this is how the game knows which lane's rules currently apply.
if (player.x < PLAYER_SIZE / 2 || player.x > width - PLAYER_SIZE / 2) { gameState = 'GAME_OVER'; }
A safety check - though move() already constrains x, this double-checks the player hasn't ended up off-canvas.

drawGameOverScreen()

This function runs every single frame while in GAME_OVER state, which is why updateHighestScore() includes its own internal check to avoid re-saving unnecessarily.

function drawGameOverScreen() {
  updateHighestScore(); // Update highest score before displaying game over screen

  fill(0, 150);
  rectMode(CORNER);
  rect(0, 0, width, height);

  fill(255);
  textAlign(CENTER, CENTER);
  textSize(64);
  text("GAME OVER", width / 2, height / 2 - 100);
  textSize(32);
  text("Your Score: " + score, width / 2, height / 2);
  text("Highest Score: " + highestScore, width / 2, height / 2 + 50);
  textSize(24);
  text("Press Any Key to Restart", width / 2, height / 2 + 100);
}
Line-by-line explanation (3 lines)
updateHighestScore(); // Update highest score before displaying game over screen
Checks and possibly saves a new personal best before the screen is drawn, so the displayed number is always current.
fill(0, 150);
Sets a semi-transparent black fill (alpha 150 out of 255) so the game scene is still faintly visible behind the overlay.
rect(0, 0, width, height);
Draws a full-screen dark overlay rectangle to dim the frozen game behind the game-over text.

keyPressed()

keyPressed() is a built-in p5.js callback that fires once per key press, letting you map keyCode values to game actions.

function keyPressed() {
  if (gameState === 'START' || gameState === 'GAME_OVER') {
    gameState = 'PLAYING';
    if (gameState === 'GAME_OVER') {
      resetGame();
    }
    return false;
  }

  if (gameState === 'PLAYING') {
    switch (keyCode) {
      case UP_ARROW:
        player.move(0, LANE_HEIGHT);
        break;
      case DOWN_ARROW:
        player.move(0, -LANE_HEIGHT);
        // Prevent player from moving below initial starting position
        playerActualY = min(playerActualY, playerCanvasY);
        break;
      case LEFT_ARROW:
        player.move(-LANE_HEIGHT, 0); // Move left by one laneHeight step (for simplicity, could be smaller)
        break;
      case RIGHT_ARROW:
        player.move(LANE_HEIGHT, 0); // Move right by one laneHeight step
        break;
    }
    return false; // Prevent default browser behavior
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Start / Restart Handling if (gameState === 'START' || gameState === 'GAME_OVER') {

Any key press from the start or game-over screens begins (or restarts) the game

switch-case Arrow Key Movement switch (keyCode) {

Maps each arrow key to a specific move() call on the player

if (gameState === 'START' || gameState === 'GAME_OVER') {
Any key press works to leave the start screen or the game-over screen.
gameState = 'PLAYING';
Switches straight into gameplay mode.
if (gameState === 'GAME_OVER') { resetGame(); }
Intended to reset the game when restarting after a loss - but since gameState was just set to 'PLAYING' on the line above, this condition is always false (see improvements).
case UP_ARROW: player.move(0, LANE_HEIGHT); break;
Pressing up moves the player forward by one lane height.
playerActualY = min(playerActualY, playerCanvasY);
Clamps the player's world y so moving down can never push them below their original starting row.
return false;
Prevents the browser's default behavior for arrow keys (like scrolling the page).

touchStarted()

touchStarted() is p5.js's built-in mobile touch callback, mirroring keyPressed() so the game is playable without a keyboard.

function touchStarted() {
  if (gameState === 'START' || gameState === 'GAME_OVER') {
    gameState = 'PLAYING';
    if (gameState === 'GAME_OVER') {
      resetGame();
    }
    return false;
  }

  if (gameState === 'PLAYING') {
    // Basic touch input: tap anywhere to move up
    // For more advanced touch, would need to detect tap direction or area
    player.move(0, LANE_HEIGHT);
    return false; // Prevent default browser behavior (scrolling)
  }
}
Line-by-line explanation (2 lines)
player.move(0, LANE_HEIGHT);
On mobile, any tap simply moves the player forward one lane - a simplified control scheme compared to the four-directional keyboard input.
return false; // Prevent default browser behavior (scrolling)
Stops the touch event from also scrolling or zooming the page on mobile browsers.

generateNewLane()

This factory function is called whenever a brand-new lane is needed - both during initial setup and continuously during play as old lanes scroll away.

🔬 Lane type is chosen with equal odds from these three strings. What happens if you duplicate 'road' in the array so it's picked twice as often - does the game feel more dangerous?

  let laneType = random(['grass', 'road', 'water']);
function generateNewLane(y) {
  let laneType = random(['grass', 'road', 'water']);
  let direction = random() < 0.5 ? 1 : -1; // 1 for right, -1 for left
  let speed;

  if (laneType === 'road') {
    speed = CAR_SPEED_FACTOR * random(0.8, 1.2);
  } else if (laneType === 'water') {
    speed = LOG_SPEED_FACTOR * random(0.8, 1.2);
  } else {
    speed = 0; // Grass lanes have no moving obstacles
  }

  return new Lane(y, laneType, speed, direction);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Speed By Lane Type if (laneType === 'road') {

Assigns a base speed depending on whether the new lane is road, water, or grass

let laneType = random(['grass', 'road', 'water']);
Picks one of three lane types completely at random using p5's random() with an array argument.
let direction = random() < 0.5 ? 1 : -1;
A coin-flip decides whether this lane's obstacles travel right (1) or left (-1).
speed = CAR_SPEED_FACTOR * random(0.8, 1.2);
Adds slight per-lane speed variation (80%-120% of the base) so not every road lane feels identical.
return new Lane(y, laneType, speed, direction);
Builds and returns the actual Lane object using the randomly chosen parameters.

resetGame()

resetGame() is called both at the very first launch and every time a new game begins after game over - it rebuilds all state from scratch, which is why fixing the restart bug (see improvements) matters.

function resetGame() {
  score = 0;
  highestScore = getItem('crossyRoadHighestScore') || 0; // Load highest score
  playerCanvasY = height - LANE_HEIGHT * PLAYER_START_LANE_INDEX - LANE_HEIGHT / 2;
  playerActualY = playerCanvasY;
  player = new Player(width / 2, playerCanvasY);
  lanes = [];

  // Generate initial lanes, starting from the player's lane and moving upwards
  for (let i = 0; i < NUM_PREGEN_LANES; i++) {
    let y = playerCanvasY - LANE_HEIGHT * i;
    lanes.push(generateNewLane(y));
  }

  // Ensure the starting lanes are safe (grass)
  for (let i = 0; i < PLAYER_START_LANE_INDEX; i++) {
    lanes[i].type = 'grass';
    lanes[i].obstacles = [];
    lanes[i].speed = 0;
  }
  
  scrollOffset = 0;
  gameState = 'START';
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Generate Starting Lanes for (let i = 0; i < NUM_PREGEN_LANES; i++) {

Builds the full stack of lanes above the player before the game begins

for-loop Force Safe Starting Lanes for (let i = 0; i < PLAYER_START_LANE_INDEX; i++) {

Overwrites the first few lanes near the player to be safe grass, avoiding an instant-death start

playerCanvasY = height - LANE_HEIGHT * PLAYER_START_LANE_INDEX - LANE_HEIGHT / 2;
Calculates the fixed screen y where the player will always be drawn, based on how many lanes from the bottom they start.
player = new Player(width / 2, playerCanvasY);
Creates a brand new Player object horizontally centered at the starting row.
let y = playerCanvasY - LANE_HEIGHT * i;
Each new lane is placed one LANE_HEIGHT above the previous one, building a stack going upward from the player.
lanes[i].type = 'grass'; lanes[i].obstacles = []; lanes[i].speed = 0;
Forces the first few lanes near the player to be safe grass with no obstacles, guaranteeing a fair start.

windowResized()

windowResized() is a p5.js lifecycle function you can define to make your sketch responsive to browser window changes.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  // Re-initialize game elements for responsive layout
  // This is a quick fix, ideally parameters like LANE_HEIGHT should be dynamic.
  highestScore = getItem('crossyRoadHighestScore') || 0;
  resetGame();
  // playerCanvasY and player.y are updated in resetGame
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
A built-in p5.js callback that fires automatically whenever the browser window is resized; this resizes the canvas to match.
resetGame();
Rebuilds the entire game layout from scratch to fit the new canvas size - simple but means any in-progress run is lost on resize.

updateHighestScore()

This function demonstrates simple persistent state in a web sketch - getItem/storeItem wrap the browser's localStorage API so scores survive page reloads.

function updateHighestScore() {
  if (score > highestScore) {
    highestScore = score;
    storeItem('crossyRoadHighestScore', highestScore); // Save highest score
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional New High Score Check if (score > highestScore) {

Only updates and saves when the current score actually beats the previous record

if (score > highestScore) {
Only proceeds if the just-finished run set a new personal best.
storeItem('crossyRoadHighestScore', highestScore);
p5.js's storeItem() saves a value into the browser's local storage under a string key, so it persists even after the page is closed and reopened.

📦 Key Variables

LANE_HEIGHT number

Pixel height of every lane strip; also used as the movement step size for the player.

const LANE_HEIGHT = 60;
PLAYER_SIZE number

Width/height of the player's square, derived from LANE_HEIGHT so it always fits nicely within a lane.

const PLAYER_SIZE = LANE_HEIGHT * 0.8;
NUM_PREGEN_LANES number

How many lanes are kept generated above the screen at all times.

const NUM_PREGEN_LANES = 15;
PLAYER_START_LANE_INDEX number

How many lanes from the bottom the player begins on, used to guarantee a few safe grass lanes at the start.

const PLAYER_START_LANE_INDEX = 2;
OBSTACLE_DENSITY number

Probability (0-1) that any given gap in a lane spawns a car or log.

const OBSTACLE_DENSITY = 0.3;
CAR_SPEED_FACTOR number

Base multiplier for how fast cars move in road lanes.

const CAR_SPEED_FACTOR = 1.5;
LOG_SPEED_FACTOR number

Base multiplier for how fast logs drift in water lanes.

const LOG_SPEED_FACTOR = 1;
player object

The single Player instance representing the character being controlled.

let player;
lanes array

Holds all currently active Lane objects, ordered roughly by vertical position.

let lanes = [];
scrollOffset number

The vertical pixel offset added when drawing lanes/obstacles to simulate the camera scrolling as the player advances.

let scrollOffset = 0;
playerCanvasY number

The player's fixed, never-changing y position on the actual screen.

let playerCanvasY;
playerActualY number

The player's true position within the endless game world, used for scoring and determining which lane they're in.

let playerActualY;
score number

Tracks the farthest distance traveled this run; only ever increases via max().

let score = 0;
highestScore number

The best score ever achieved, loaded from and saved to local storage.

let highestScore = 0;
gameState string

Current screen/mode of the game - 'START', 'PLAYING', or 'GAME_OVER' - controlling what draw() renders and how input is handled.

let gameState = 'START';

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG keyPressed() and touchStarted()

The restart logic sets `gameState = 'PLAYING';` before checking `if (gameState === 'GAME_OVER') { resetGame(); }`, so that condition is always false and resetGame() never actually runs when restarting after a loss.

💡 Save the previous state first, e.g. `let wasGameOver = gameState === 'GAME_OVER'; gameState = 'PLAYING'; if (wasGameOver) resetGame();` so the game properly resets on restart.

PERFORMANCE Lane.update()

Array.filter() allocates a brand-new array for every lane on every single frame, even when no obstacles have gone off-screen, which adds unnecessary garbage collection pressure with many lanes.

💡 Only filter when at least one obstacle actually reports isOffScreen(), or use a manual splice-in-place loop to avoid reallocating arrays every frame.

STYLE Lane.generateObstacles() and Lane.update()

The obstacle-width and spawn-position logic is duplicated almost identically between generateObstacles() and update(), making future changes error-prone (you'd need to edit both places consistently).

💡 Extract a shared helper like `createRandomObstacle(x, type, speed)` that both methods call, keeping obstacle-creation rules in one place.

FEATURE playGame()

Player movement is an instant teleport by LANE_HEIGHT with no animation, which can feel abrupt compared to the classic Crossy Road hop.

💡 Store a target x/y and lerp() the player's drawn position toward it over a few frames to create a smooth hopping animation.

🔄 Code Flow

Code flow showing playerconstructor, playerdraw, playermove, playerupdateonlog, playerchecksafety, obstacleconstructor, obstacledraw, obstacleupdate, obstacleisoffscreen, laneconstructor, lanegenerateobstacles, lanedraw, laneupdate, setup, draw, drawstartscreen, playgame, drawgameoverscreen, keypressed, touchstarted, generatenewlane, resetgame, windowresized, updatehighestscore

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> state-switch[state-switch] state-switch --> drawstartscreen[drawstartscreen] state-switch --> playgame[playgame] click setup href "#fn-setup" click draw href "#fn-draw" click state-switch href "#sub-state-switch" click drawstartscreen href "#fn-drawstartscreen" click playgame href "#fn-playgame" playgame --> lanes-update-loop[lanes-update-loop] lanes-update-loop --> laneupdate[laneupdate] laneupdate --> obstacle-move-loop[obstacle-move-loop] obstacle-move-loop --> obstacleupdate[obstacleupdate] obstacle-move-loop --> obstacle-draw-loop[obstacle-draw-loop] obstacle-draw-loop --> obstacledraw[obstacledraw] lanes-update-loop --> find-player-lane[find-player-lane] find-player-lane --> playerchecksafety[playerchecksafety] playerchecksafety --> onlog-check[onlog-check] playerchecksafety --> road-collision-loop[road-collision-loop] click playgame href "#fn-playgame" click lanes-update-loop href "#sub-lanes-update-loop" click laneupdate href "#fn-laneupdate" click obstacle-move-loop href "#sub-obstacle-move-loop" click obstacleupdate href "#fn-obstacleupdate" click obstacle-draw-loop href "#sub-obstacle-draw-loop" click obstacledraw href "#fn-obstacledraw" click find-player-lane href "#sub-find-player-lane" click playerchecksafety href "#fn-playerchecksafety" click onlog-check href "#sub-onlog-check" click road-collision-loop href "#sub-road-collision-loop" draw --> keypressed[keypressed] keypressed --> start-restart-check[start-restart-check] start-restart-check --> movement-switch[movement-switch] movement-switch --> playermove[playermove] click keypressed href "#fn-keypressed" click start-restart-check href "#sub-start-restart-check" click movement-switch href "#sub-movement-switch" click playermove href "#fn-playermove" resetgame[resetgame] --> initial-lane-loop[initial-lane-loop] initial-lane-loop --> safe-start-loop[safe-start-loop] initial-lane-loop --> generatenewlane[generatenewlane] click resetgame href "#fn-resetgame" click initial-lane-loop href "#sub-initial-lane-loop" click safe-start-loop href "#sub-safe-start-loop" click generatenewlane href "#fn-generatenewlane" updatehighestscore[updatehighestscore] --> new-record-check[new-record-check] click updatehighestscore href "#fn-updatehighestscore" click new-record-check href "#sub-new-record-check"

❓ Frequently Asked Questions

What does the Crossy Road sketch create visually?

The Crossy Road sketch visually represents a top-down view of a character navigating through various lanes filled with obstacles like cars and logs. The lanes are designed to scroll vertically, giving the impression of movement as the player character attempts to avoid collisions and move upwards.

How can users interact with the Crossy Road sketch?

Users can interact with the Crossy Road sketch by using keyboard controls to move the player character left and right across the lanes. The game also features a scoring system that tracks how far the player progresses vertically in the game.

What creative coding technique does the Crossy Road sketch demonstrate?

The Crossy Road sketch demonstrates the use of object-oriented programming in p5.js through the implementation of classes for the player and obstacles. This technique helps in organizing the code for better management of game entities and their behaviors.

How could someone recreate a similar effect in p5.js?

To recreate a similar effect in p5.js, one can use arrays to manage multiple lanes and obstacles, and implement a scrolling background to simulate movement. Incorporating keyboard controls for player movement and a scoring system to track progress will enhance interactivity.

Preview

Crossy rpad - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Crossy rpad - Code flow showing playerconstructor, playerdraw, playermove, playerupdateonlog, playerchecksafety, obstacleconstructor, obstacledraw, obstacleupdate, obstacleisoffscreen, laneconstructor, lanegenerateobstacles, lanedraw, laneupdate, setup, draw, drawstartscreen, playgame, drawgameoverscreen, keypressed, touchstarted, generatenewlane, resetgame, windowresized, updatehighestscore
Code Flow Diagram